import re
import os
from collections import namedtuple


class ComponentParserError(Exception):
    '''Exception raised by the `ComponentParser` class.'''
    
    pass


class Token(namedtuple('Token', 'data kind')):

    IDENTIFIER = 0x1
    ASSIGNMENT = 0x2
    VALUE = 0x3

    def is_list(self):
        '''Return *True* if `data` represents a list of values.'''

        return ':' in self.data

    def values(self):
        '''Return a list of values.

        If the token is not a list return a single-element
        list that contains the whole value,
        otherwise it contains the elements of the list.
        '''

        return self.data.split(':')


class ComponentInfos(object):
    '''Contains all the informations about a given component.'''
    
    def __init__(self, name, version, short_description,
                       author, directory, filename,
                       abilities, interface,
                       full_name=None, long_description=None,
                       author_email=None, license=None,
                       website=None, documentation=None,
                       bug_tracker=None, requires=None,
                       revision_version=None, class_name=None):
        self.name = name
        self.version = version
        self.short_description = short_description
        self.author = author
        self.directory = directory
        self.filename = filename
        self.abilities = abilities
        self.interface = interface

        self.full_name = full_name if full_name is not None else name
        self.long_description = long_description
        self.author_email = author_email
        self.license = license
        self.website = website
        self.documentation = documentation
        self.bug_tracker = bug_tracker
        self.requires = requires
        self.revision_version = revision_version if revision_version is not None \
                                else version
        self.class_name = class_name if class_name is not None else self.full_name
    
    def __eq__(self, other):
        attrs = (
            'name', 'version', 'short_description', 'author',
            'directory', 'filename', 'abilities', 'interface',
            'full_name', 'long_description', 'author_email',
            'license', 'website', 'documentation', 'bug_tracker',
            'requires', 'revision_version', 'class_name')
        for attr in attrs:
            try:
                if getattr(self, attr) != getattr(other, attr):
                    break
            except AttributeError:
                break
        else:
            return True
        return False
    
    def __ne__(self, other):
        return not self == other


class ComponentParser(object):
    '''Parse component files and return `ComponentInfos` objects.'''
    
    def __init__(self):
        self._can_have_lists = (
            'author',
            'author_email',
            'license',
            'directory',
            'filename',
            'abilities',
            'requires',
            )
        
        self._must_have_list_value = (
            'abilities',
            )
        
        self._can_have_colons = (
            'website',
            'bug_tracker',
            )

        self._identifier_to_validator = {
            'name' : self._validate_alnum(3),
            'version' : self._validate_version,
            'filename' : self._validate_filename,
            'interface' : self._validate_interface,
            'website' : self._validate_url,
            'bug_tracker' : self._validate_url,
            'class_name' : self._validate_class_name,
            }

    def parse(self, fobj):
        '''Parse a file-like object *fobj* and return a ComponentInfos object.'''

        infos = {}
        for identifier, value in self.parse_infos(fobj):
            if identifier in infos:
                raise ComponentParserError('The identifier %r has already a value.'
                                        % identifier)
            infos[identifier] = value

        if 'class_name' not in infos:
            try:
                name = infos.get('full_name', infos['name'])
            except KeyError:
                raise ComponentParserError('Invalid component file.')

            if not self._validate_class_name(name):
                raise ComponentParserError('The identifier \'class_name\' '
                                           'has an invalid value.')

        self._expand_paths(infos, fobj)
        
        for must_have_list_value in self._must_have_list_value:
            try:
                if not isinstance(infos[must_have_list_value], list):
                    infos[must_have_list_value] = [infos[must_have_list_value]]
            except KeyError:
                infos[must_have_list_value] = []
        
        try:
            return ComponentInfos(**infos)
        except TypeError:
            raise ComponentParserError('Invalid component file.')

    def parse_infos(self, fobj):
        '''Generator that yield informations about the file-like object *fobj*.'''

        identifier = None
        value = None
        for token in self.tokenize(fobj):
            if token.kind == Token.IDENTIFIER:
                if identifier is not None:
                    raise ComponentParserError('Multiple assignments are not '
                                               'supported.')
                identifier = token.data
            elif token.kind == Token.ASSIGNMENT:
                if token.data != '=':
                    raise ComponentParserError('Invalid assignment operator.')
            elif token.kind == Token.VALUE:
                value = self._validate_value(identifier, token.data)
                if identifier is None:
                    raise ComponentParserError('Invalid assignment.')
                yield identifier, value
                identifier = None
                value = None
            else:
                raise ComponentParserError('Unknown token type: %r.' % token.kind)
        if value or identifier:
            raise ComponentParserError('Found uncoupled identifier or value.')

    def tokenize(self, fobj):
        '''Yields tokens from the given file-like object *fobj*.

        The *fobj* object should simply implement the *__iter__* method.
        '''

        id_name = None
        value = None
        is_list = False

        for line in self._logical_lines(fobj):
            identifier, sep, value = line.partition('=')
            if not sep:
                raise ComponentParserError('Found invalid logical line: %r.'
                                           % line)
            id_name = identifier.strip()

            yield Token(id_name, Token.IDENTIFIER)
            yield Token(sep, Token.ASSIGNMENT)

            value = value.lstrip()
            is_list = self._is_list(value)
            if is_list:
                value = self._clean_list(value)
            else:
                value = self._clean_value(value)
            yield Token(value, Token.VALUE)

    def _validate_value(self, identifier, value):
        '''Validate the given value and return it in the right form.'''

        if ':' in value:
            if identifier not in self._can_have_lists and\
               identifier not in self._can_have_colons:
                raise ComponentParserError('Identifier %r cannot have list values '
                                           'or values with colons.'
                                           % identifier)

        validator = self._identifier_to_validator.get(identifier)
        if validator is None:
            if ':' in value:
                return value.split(':')
            else:
                return value
        if not validator(value):
            raise ComponentParserError('Invalid value %r for identifier %r'
                                        % (value, identifier))
        if ':' in value and identifier in self._can_have_lists:
            return value.split(':')
        else:
            return value

    def _validate_alnum(self, minimum_size=None):
        '''Return a function that validates an "alphanumeric" string.'''

        def validate_alnum_with_hyphen_and_underscore(text):
            '''Return True if the value if valid.'''

            if minimum_size is not None:
                if len(text) < minimum_size:
                    return False
            return text.replace('-','').replace('_', '').isalnum()
        return validate_alnum_with_hyphen_and_underscore

    def _validate_version(self, version_str):
        '''Return True if the given version string is valid.'''

        if not version_str.replace('.', '').isalnum():
            return False
        if version_str.count('.') != 2:
            return False

        tokens = version_str.lower().split('.')
        if not all(tok.isdigit() for tok in tokens[:-1]):
            return False
        elif tokens[-1].isdigit():
            return True
        part = ''.join(c for c in tokens[-1] if not c.isdigit())
        return part in ('a', 'b', 'rc', 'dev')

    def _validate_filename(self, value):
        '''Return True if the given filename is valid.'''

        if not isinstance(value, list):
            value = [value]
        for val in value:
            if not val.replace('-', '').replace('_', '').replace('.', '').isalnum():
                break
        else:
            return True
        return False

    def _validate_interface(self, value):
        '''Return True if the given value is a valid interface.'''

        return value in ('document', 'view',  'viewdisplayer')

    def _validate_url(self, value):
        '''Return True if the given value is a valid url.'''

        valid_chars = set('&?#@:./+-_%~[]!$\'()*,;=')
        for char in value:
            if not char.isalnum():
                if char not in valid_chars:
                    break
        else:
            return True
        return False

    def _validate_class_name(self, cls_name):
        '''Return True if *cls_name* is a valid class name.'''

        return cls_name.replace('_','').isalnum() and not cls_name[0].isdigit()

    def _is_list(self, value):
        '''Return *True* if the given value represents a list.'''

        pieces = value.split(':')
        num_true = 0
        for piece in pieces:
            num_true += int(bool(piece))
            if num_true > 1:
                break
        else:
            return False
        return True

    def _clean_list(self, value):
        '''Return a cleaned version of the list *value*.'''

        values = value.strip().split(':')
        list_val = ':'.join(val for val in values if val.strip())
        return list_val

    def _clean_value(self, value):
        '''Return a cleaned version of the single value *value*.'''

        single_value = value.strip().replace(':','')
        return single_value

    def _logical_lines(self, fobj):
        '''Yield logical lines, each terminating in a newline.'''

        previous = []
        last_had_continuation = None
        for line in fobj:
            if line.isspace():
                if previous:
                    yield ''.join(previous) + '\n'
                    previous = []
                if last_had_continuation is not None:
                    last_had_continuation = False
                continue

            if line.rstrip().endswith('\\'):
                if last_had_continuation is False and '=' not in line:
                    raise ComponentParserError('Cannot jump line continuation.')
                if '=' in line and previous:
                    yield ''.join(previous) + '\n'
                    previous = []
                previous.append(line.rstrip()[:-1].strip())
                last_had_continuation = True
            else:
                if previous and '=' not in line:
                    previous.append(line.strip())
                    yield ''.join(previous) + '\n'
                    previous = []
                else:
                    if previous:
                        yield ''.join(previous) + '\n'
                        previous = []
                    yield line.strip() + '\n'
                last_had_continuation = False
        if previous:
            yield ''.join(previous) + '\n'

    def _expand_paths(self, infos, fobj):
        '''Modifies infos paths.'''

        try:
            from_dir = os.getcwd()
            dirname = os.path.dirname(fobj.name)
            os.chdir(dirname)
        except AttributeError:
            return

        try:
            try:
                paths = infos['directory']
            except KeyError:
                raise ComponentParserError('Invalid component file.')

            if isinstance(paths, basestring):
                paths = [paths]
                was_str = True
            else:
                was_str = False

            for pos, path in enumerate(paths[:]):
                paths[pos] = os.path.realpath(path)

            if not was_str:
                infos['directory'] = paths
            else:
                infos['directory'] = paths[0]
        finally:
            os.chdir(from_dir)
